﻿//////////////////////////////////////////////////////////////////////////////
// This source code and all associated files and resources are copyrighted by
// the author(s). This source code and all associated files and resources may
// be used as long as they are used according to the terms and conditions set
// forth in The Code Project Open License (CPOL), which may be viewed at
// http://www.blackbeltcoder.com/Legal/Licenses/CPOL.
//
// Copyright (c) 2011 Jonathan Wood
//

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;

namespace BlackBeltCoder
{
    public enum FtpDirectoryFormat
    {
        Unix,
        Windows,
        Unknown
    }

    public class FtpDirectoryEntry
    {
        public DateTime CreateTime;
        public string Flags;
        public string Group; // UNIX only
        public bool IsDirectory;
        public string Name;
        public string Owner;
        public long Size;
    }

    /// <summary>
    ///     General, easy-to-use FTP class.
    /// </summary>
    public class FtpClient
    {
        protected FtpDirectory _host;

        // Construction
        public FtpClient()
        {
            _host = new FtpDirectory();
        }

        /// <summary>
        ///     Gets or sets the current FTP domain and optional directory
        /// </summary>
        public string Host
        {
            set => _host.SetUrl(value);
            get => _host.GetUrl();
        }

        /// <summary>
        ///     Gets or sets the login username.
        /// </summary>
        public string Username { get; set; }

        /// <summary>
        ///     Gets or sets the login password.
        /// </summary>
        public string Password { get; set; }

        /// <summary>
        ///     Indicates if the current directory is the
        ///     root directory.
        /// </summary>
        public bool IsRootDirectory => _host.IsRootDirectory;

        /// <summary>
        ///     Returns a directory listing of the current working directory.
        /// </summary>
        public List<FtpDirectoryEntry> ListDirectory()
        {
            var request = GetRequest();
            request.Method = WebRequestMethods.Ftp.ListDirectoryDetails;

            string listing;
            using (var response = request.GetResponse() as FtpWebResponse)
            {
                var sr = new StreamReader(response.GetResponseStream(),
                    Encoding.UTF8);
                listing = sr.ReadToEnd();
                response.Close();
            }
            return ParseDirectoryListing(listing);
        }

        /// <summary>
        ///     Changes the current working directory. If directory starts with "/" then it
        ///     is relative to the root directory. If directory is ".." then it refers to
        ///     the parent directory.</param>
        /// </summary>
        /// <param name="directory">Directory to make active.</param>
        public void ChangeDirectory(string directory)
        {
            _host.CurrentDirectory = directory;
        }

        /// <summary>
        ///     Indicates if the specified directory exists. This function returns false
        ///     if a filename existing with the given name.
        /// </summary>
        /// <param name="directory">Directory to test. May be relative or absolute.</param>
        /// <returns></returns>
        public bool DirectoryExists(string directory)
        {
            try
            {
                var request = GetRequest(directory);
                request.Method = WebRequestMethods.Ftp.ListDirectory;

                using (var response = request.GetResponse() as FtpWebResponse)
                {
                    var sr = new StreamReader(response.GetResponseStream(),
                        Encoding.ASCII);
                    sr.ReadToEnd();
                    sr.Close();
                    response.Close();
                }
                return true;
            }
            catch
            {
            }
            return false;
        }

        /// <summary>
        ///     Creates the specified directory. This method will create multiple levels of
        ///     subdirectories as needed.
        /// </summary>
        /// <param name="directory">Directory to create. May be relative or absolute.</param>
        public void CreateDirectory(string directory)
        {
            // Get absolute directory
            directory = _host.ApplyDirectory(directory);

            // Split into path components
            var steps = directory.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries);

            // Build list of full paths to each path component
            var paths = new List<string>();
            for (var i = 1; i <= steps.Length; i++)
                paths.Add(FtpDirectory.ForwardSlash + string.Join(FtpDirectory.ForwardSlash, steps, 0, i));

            // Find first path component that needs creating
            int createIndex;
            for (createIndex = paths.Count; createIndex > 0; createIndex--)
                if (DirectoryExists(paths[createIndex - 1]))
                    break;

            // Created needed paths
            for (; createIndex < paths.Count; createIndex++)
            {
                var request = GetRequest(paths[createIndex]);
                request.Method = WebRequestMethods.Ftp.MakeDirectory;
                var response = (FtpWebResponse) request.GetResponse();
                response.Close();
            }
        }

        /// <summary>
        ///     Uploads the given list of files to the current working directory.
        /// </summary>
        /// <param name="paths">List of local files to upload</param>
        public void UploadFiles(params string[] paths)
        {
            foreach (var path in paths)
            {
                var request = GetRequest(Path.GetFileName(path));
                request.Method = WebRequestMethods.Ftp.UploadFile;
                request.UseBinary = true;

                var info = new FileInfo(path);
                request.ContentLength = info.Length;

                // Create buffer for file contents
                var buffLength = 16384;
                var buff = new byte[buffLength];

                // Upload this file
                using (var instream = info.OpenRead())
                {
                    using (var outstream = request.GetRequestStream())
                    {
                        var bytesRead = instream.Read(buff, 0, buffLength);
                        while (bytesRead > 0)
                        {
                            outstream.Write(buff, 0, bytesRead);
                            bytesRead = instream.Read(buff, 0, buffLength);
                        }
                        outstream.Close();
                    }
                    instream.Close();
                }

                var response = (FtpWebResponse) request.GetResponse();
                response.Close();
            }
        }

        /// <summary>
        ///     Downloads the given list of files to the specified local target path
        /// </summary>
        /// <param name="path">Location where downloaded files will be saved</param>
        /// <param name="files">Names of files to download from current FTP directory</param>
        public void DownloadFiles(string path, params string[] files)
        {
            foreach (var file in files)
            {
                var request = GetRequest(file);
                request.Method = WebRequestMethods.Ftp.DownloadFile;
                request.UseBinary = true;

                using (var outstream = new FileStream(Path.Combine(path, file), FileMode.Create))
                {
                    var response = (FtpWebResponse) request.GetResponse();
                    using (var instream = response.GetResponseStream())
                    {
                        var buffLength = 16384;
                        var buffer = new byte[buffLength];

                        var bytesRead = instream.Read(buffer, 0, buffLength);
                        while (bytesRead > 0)
                        {
                            outstream.Write(buffer, 0, bytesRead);
                            bytesRead = instream.Read(buffer, 0, buffLength);
                        }
                        instream.Close();
                    }
                    outstream.Close();
                    response.Close();
                }
            }
        }

        /// <summary>
        ///     Deletes the given list of files from the current working directory.
        /// </summary>
        /// <param name="files">List of files to delete.</param>
        public void DeleteFiles(params string[] files)
        {
            foreach (var file in files)
            {
                var request = GetRequest(file);
                request.Method = WebRequestMethods.Ftp.DeleteFile;
                var response = (FtpWebResponse) request.GetResponse();
                response.Close();
            }
        }

        /// <summary>
        ///     Deletes the specified directory. The directory should be empty.
        /// </summary>
        /// <param name="files">Directory to delete.</param>
        public void DeleteDirectory(string directory)
        {
            var request = GetRequest(directory);
            request.Method = WebRequestMethods.Ftp.RemoveDirectory;
            var response = (FtpWebResponse) request.GetResponse();
            response.Close();
        }

        #region Protected helper methods

        // Constructs an FTP web request
        protected FtpWebRequest GetRequest()
        {
            return GetRequest("");
        }

        // Constructs an FTP web request with the given filename
        protected FtpWebRequest GetRequest(string filename)
        {
            var url = _host.GetUrl(filename);
            var request = WebRequest.Create(url) as FtpWebRequest;
            request.Credentials = new NetworkCredential(Username, Password);
            request.Proxy = null;
            request.KeepAlive = false;
            return request;
        }

        private delegate FtpDirectoryEntry ParseLine(string lines);

        // Converts a directory listing to a list of FtpDirectoryEntrys
        protected List<FtpDirectoryEntry> ParseDirectoryListing(string listing)
        {
            ParseLine parseFunction = null;
            var entries = new List<FtpDirectoryEntry>();
            var lines = listing.Split('\n');
            var format = GuessDirectoryFormat(lines);

            if (format == FtpDirectoryFormat.Windows)
                parseFunction = ParseWindowsDirectoryListing;
            else if (format == FtpDirectoryFormat.Unix)
                parseFunction = ParseUnixDirectoryListing;

            if (parseFunction != null)
                foreach (var line in lines)
                    if (line.Length > 0)
                    {
                        var entry = parseFunction(line);
                        if (entry.Name != "." && entry.Name != "..")
                            entries.Add(entry);
                    }
            return entries;
            ;
        }

        // Attempts to determine the directory format.
        protected FtpDirectoryFormat GuessDirectoryFormat(string[] lines)
        {
            foreach (var s in lines)
                if (s.Length > 10 && Regex.IsMatch(s.Substring(0, 10),
                        "(-|d)(-|r)(-|w)(-|x)(-|r)(-|w)(-|x)(-|r)(-|w)(-|x)"))
                    return FtpDirectoryFormat.Unix;
                else if (s.Length > 8 && Regex.IsMatch(s.Substring(0, 8), "[0-9][0-9]-[0-9][0-9]-[0-9][0-9]"))
                    return FtpDirectoryFormat.Windows;
            return FtpDirectoryFormat.Unknown;
        }

        // Parses a line from a Windows-format listing
        // 
        // Assumes listing style as:
        // 02-03-04  07:46PM       <DIR>          Append
        protected FtpDirectoryEntry ParseWindowsDirectoryListing(string text)
        {
            var entry = new FtpDirectoryEntry();

            text = text.Trim();
            var dateStr = text.Substring(0, 8);
            text = text.Substring(8).Trim();
            var timeStr = text.Substring(0, 7);
            text = text.Substring(7).Trim();
            entry.CreateTime = DateTime.Parse(string.Format("{0} {1}", dateStr, timeStr));
            if (text.Substring(0, 5) == "<DIR>")
            {
                entry.IsDirectory = true;
                text = text.Substring(5).Trim();
            }
            else
            {
                entry.IsDirectory = false;
                var pos = text.IndexOf(' ');
                entry.Size = long.Parse(text.Substring(0, pos));
                text = text.Substring(pos).Trim();
            }
            entry.Name = text; // Rest is name

            return entry;
        }

        // Parses a line from a UNIX-format listing
        // 
        // Assumes listing style as:
        // dr-xr-xr-x   1 owner    group               0 Nov 25  2002 bussys
        protected FtpDirectoryEntry ParseUnixDirectoryListing(string text)
        {
            // Assuming record style as
            // dr-xr-xr-x   1 owner    group               0 Nov 25  2002 bussys
            var entry = new FtpDirectoryEntry();
            var processstr = text.Trim();
            entry.Flags = processstr.Substring(0, 9);
            entry.IsDirectory = entry.Flags[0] == 'd';
            processstr = processstr.Substring(11).Trim();
            CutSubstringWithTrim(ref processstr, ' ', 0); //skip one part
            entry.Owner = CutSubstringWithTrim(ref processstr, ' ', 0);
            entry.Group = CutSubstringWithTrim(ref processstr, ' ', 0);
            CutSubstringWithTrim(ref processstr, ' ', 0); //skip one part
            var strTime = CutSubstringWithTrim(ref processstr, ' ', 8);
            entry.CreateTime = DateTime.Parse(strTime);
            entry.Name = processstr; //Rest of the part is name
            return entry;
        }

        // Removes the token ending in the specified character
        protected string CutSubstringWithTrim(ref string s, char c, int startIndex)
        {
            var pos = s.IndexOf(c, startIndex);
            if (pos < 0) pos = s.Length;
            var retString = s.Substring(0, pos);
            s = s.Substring(pos).Trim();
            return retString;
        }

        #endregion
    }

    /// <summary>
    ///     Helper class for managing current FTP directory.
    /// </summary>
    public class FtpDirectory
    {
        // Static members
        protected static char[] _slashes = {'/', '\\'};

        public static string BackSlash = "\\";
        public static string ForwardSlash = "/";
        protected string _cwd; // Leading, no trailing slash

        // Member variables
        protected string _domain; // No trailing slash

        // Construction
        public FtpDirectory()
        {
            _domain = string.Empty;
            _cwd = ForwardSlash; // Root directory
        }

        public string Domain => _domain;

        /// <summary>
        ///     Determines if the current directory is the root directory.
        /// </summary>
        public bool IsRootDirectory => _cwd == ForwardSlash;

        /// <summary>
        ///     Gets or sets the current FTP directory.
        /// </summary>
        public string CurrentDirectory
        {
            get => _cwd;
            set => _cwd = ApplyDirectory(value);
        }

        /// <summary>
        ///     Sets the domain and current directory from a URL.
        /// </summary>
        /// <param name="url">URL to set to</param>
        public void SetUrl(string url)
        {
            // Separate domain from directory
            var pos = url.IndexOf("://");
            pos = url.IndexOfAny(_slashes, pos < 0 ? 0 : pos + 3);
            if (pos < 0)
            {
                _domain = url;
                _cwd = ForwardSlash;
            }
            else
            {
                _domain = url.Substring(0, pos);
                // Normalize directory string
                _cwd = ApplyDirectory(url.Substring(pos));
            }
        }

        /// <summary>
        ///     Returns the domain and current directory as a URL.
        /// </summary>
        public string GetUrl()
        {
            return GetUrl(string.Empty);
        }

        /// <summary>
        ///     Returns the domain and specified directory as a URL.
        /// </summary>
        /// <param name="directory">
        ///     Partial directory or filename applied to the
        ///     current working directory.
        /// </param>
        public string GetUrl(string directory)
        {
            if (directory.Length == 0)
                return _domain + _cwd;
            return _domain + ApplyDirectory(directory);
        }

        /// <summary>
        ///     Applies the given directory to the current directory and returns the
        ///     result.
        ///     If directory starts with "/", it replaces all of the current directory.
        ///     If directory is "..", the top-most subdirectory is removed from
        ///     the current directory.
        /// </summary>
        /// <param name="directory">The directory to apply</param>
        public string ApplyDirectory(string directory)
        {
            // Normalize directory
            directory = directory.Trim();
            directory = directory.Replace(BackSlash, ForwardSlash);
            directory = directory.TrimEnd(_slashes);

            if (directory == "..")
            {
                var pos = _cwd.LastIndexOf(ForwardSlash);
                return pos <= 0 ? ForwardSlash : _cwd.Substring(0, pos);
            }
            if (directory.StartsWith(ForwardSlash))
                return directory;
            // Relative to current directory
            if (_cwd == ForwardSlash)
                return _cwd + directory;
            return _cwd + ForwardSlash + directory;
        }

        /// <summary>
        ///     Returns the domain and current directory as a URL
        /// </summary>
        /// <returns></returns>
        public override string ToString()
        {
            return GetUrl();
        }
    }
}